Electron IPC 封装实现优雅通信

Electron 中,存在主进程渲染器进程两大进程,两者需要通过进程间通信(IPC)方式进行通信。在实际开发中,Electron 提供的 IPC 接口过于底层,使用起来有些繁琐、不易维护。

我希望能够屏蔽掉底层接口,在代码层面,实现两个进程以完全相同的实例调用的方法,来进行 IPC 通信。通过框架,将底层通信封装在内部。站在开发者角度,IPC 调用就像普通实际调用,接口声明、方法名、参数具备严格一致性

在开发 maxiee/RayBook 的过程中,我完成了这个框架,这套框架很简单,底层包括两个部分:IPC包装器IPC客户端代理

在本文中介绍该框架的实现以及使用。


IPC包装器

我们创建了一个IPC包装器来简化主进程中的IPC处理程序注册过程:

// IpcWrapper.ts
import { ipcMain } from "electron";

export function registerIpcHandlers(service: any) {
  for (const method of Object.getOwnPropertyNames(Object.getPrototypeOf(service))) {
    if (typeof service[method] === 'function') {
      ipcMain.handle(`${service.constructor.name}:${method}`, async (event, ...args) => {
        try {
          return await service[method](...args);
        } catch (error) {
          console.error(`Error in ${service.constructor.name}:${method}:`, error);
          throw error;
        }
      });
    }
  }
}

这个包装器自动为服务对象的每个方法注册一个IPC处理程序,大大简化了代码。


IPC客户端代理

在渲染进程中,我们使用代理模式来创建一个无缝的服务接口:

// IpcClient.ts
const { ipcRenderer } = window.require('electron');

export function createIpcProxy<T extends object>(serviceName: string): T {
  return new Proxy({} as T, {
    get: (target, prop) => {
      return (...args: any[]) => ipcRenderer.invoke(`${serviceName}:${prop.toString()}`, ...args);
    }
  });
}

这个代理允许我们在渲染进程中像调用本地方法一样调用主进程的方法。


实例:创建一个模块

下面,让我们来创建一个 IPC 通信模块。以 maxiee/RayBook 的 BookService 为例,这是一个图书管理服务。

首先,创建一个接口 src/services/book/BookServiceInterface.ts:

import { ApiResponse } from "../../core/ipc/ApiResponse";
import { IBook } from "../../models/Book";

export interface IBookService {
  addBookByModel(book: Partial<IBook>): Promise<ApiResponse<IBook>>;

  addBook(): Promise<ApiResponse<IBook>>;

  updateBook(book: Partial<IBook>): Promise<ApiResponse<IBook | null>>;

  findBookById(id: Id): Promise<ApiResponse<IBook>>;

  getLatestBooks(
    page: number,
    pageSize: number
  ): Promise<ApiResponse<{ books: IBook[]; total: number }>>;

  batchParseBooksInDirectory(
    directory: string
  ): Promise<ApiResponse<BookWithFiles[]>>;
}

其中,ApiResponse 是我封装的一个统一消息类:

export interface ApiResponse<T = any> {
    success: boolean;
    message: string;
    payload?: T;
}

接下来,创建主进程的实现类 src/services/book/BookServiceImpl.ts

class BookService implements IBookService {
  async addBookByModel(book: Partial<IBook>): Promise<ApiResponse<IBook>> {/*...*/}
  
  async addBook(): Promise<ApiResponse<IBook>> {/*...*/}

  async updateBook(book: Partial<IBook>): Promise<ApiResponse<IBook>> {/*...*/}
  
  /**
   * Retrieves the latest books based on the specified page and page size.
   * @param page - The page number.
   * @param pageSize - The number of books per page.
   * @returns A promise that resolves to an ApiResponse object containing the fetched books and total count.
   */
  async getLatestBooks(
    page: number,
    pageSize: number
  ): Promise<ApiResponse<{ books: IBook[]; total: number }>> {/*...*/}

  /**
   * Finds a book by its ID.
   * @param id - The ID of the book to find.
   * @returns A promise that resolves to the found book, or null if not found.
   */
  async findBookById(id: Id): Promise<ApiResponse<IBook>> {/*...*/}
}

export const bookService = new BookService();

我把实现省略了,感兴趣的同学可以浏览 BookService 完整实现


如何使用

首先,在主进程,bookService 实例本身就是一个全局单例,可以直接使用。

除此之外,在本文的 IPC 场景下,需要使用 IPC 包装器来包装 bookService,相当于一个注册过程:

// 注册 IPC 处理程序
registerIpcHandlers(bookService);
registerIpcHandlers(bookFileService);
registerIpcHandlers(bookCoverService);
registerIpcHandlers(fileService);
registerIpcHandlers(epubService);

来到渲染进程,需要创建 IPC 客户端代理:

export const bookServiceRender = createIpcProxy<IBookService>("BookService");
export const bookFileServiceRender =
  createIpcProxy<IBookFileService>("BookFileService");
export const bookCoverServiceRender =
  createIpcProxy<IBookCoverService>("BookCoverService");
export const fileServiceRender = createIpcProxy<IFileService>("FileService");
export const epubServiceRender = createIpcProxy<IEpubService>("EpubService");

可以看出,在渲染进程的服务,我都添加了 "Render" 后缀,用来加以区分。

这个框架最优雅的地方在与,bookServiceRender 拥有完整的代码提示,跟直接使用 bookService 一样。比如,下面是一处具体使用案例:

const fetchLatestBooks = async (page: number) => {
  const result = await bookServiceRender.getLatestBooks(page, pageSize);
  console.log("fetchLatestBooks result: ", result);
  if (result.success) {
    setBooks(result.payload.books);
    setTotalBooks(result.payload.total);
    setCurrentPage(page);
  } else {
    console.error("Failed to fetch latest books:", result.message);
  }
};

通过这段代码,能够感受到,相对于原始的 Electron IPC 通信 API,经过封装后,使用起来更加优雅,代码更加可维护。


本文作者:Maeiee

本文链接:Electron IPC 封装实现优雅通信

版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!


喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!